Hacks布局篇-Hack5 创建定制的ViewGroup

自定义ViewGroup

作者:李旺成
时间:2016年5月7日


该 Hack 将介绍如何自定义 ViewGroup。

显示扑克牌布局

假设现在要开发一款扑克牌游戏,需要创建类似下图的布局来显示玩家手里的牌。应该如何创建这样的布局呢?

扑克牌游戏中的玩家手牌

要创建上面的布局并不困难,使用 margin 属性便足以实现上述效果。例如,直接在 merge 标签下排布 View 即可。

使用 标签实现扑克牌布局

效果如下:

使用margin实现扑克牌布局1

看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="30dp"
android:layout_marginTop="20dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="60dp"
android:layout_marginTop="40dp"
android:background="#0000FF" />
</merge>

使用 RelativeLayout 实现扑克牌布局

使用 RelativeLayout 那更没有问题了,效果如下:

使用margin实现扑克牌布局2

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="30dp"
android:layout_marginTop="20dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="60dp"
android:layout_marginTop="40dp"
android:background="#0000FF" />
</RelativeLayout>

创建自定义 ViewGroup

使用 margin 并不是这个 Hack 要讲解的内容,这里将介绍另一种方法:创建自定义 ViewGroup。

该方法相对于在 xml 文件中手工指定 margin 有如下优点:

  • 在不同 Activity 中复用该视图时,更容易维护
  • 开发者可以使用自定义属性来定制 ViewGroup 中子视图的位置
  • 布局文件更简明,更容易理解
  • 如果需要修改 margin,不必重新手动计算每个子视图的 margin

(参考自:《50 Android Hacks》)

要自定义 ViewGroup 那需要先了解下 Android 中是怎么绘制 View 的。

View 的绘制流程

Android 的官方文档上有一篇文章专门讲解了 View 是怎么绘制的,有兴趣的可以去看看:How Android Draws Views (Google 的官方文档要翻墙,你也可以去这里看:How Android Draws Views,国内的 Android 在线文档网站,在以前的文章中推荐过)。

可能没有很多人喜欢看英文,我搬运一下《50 Android Hacks》上的介绍:
绘制布局由两个遍历过程组成:测量过程和布局过程。测量过程由 measure(int, int) 方法完成,该方法从上到下遍历视图树。在递归遍历过程中,每个视图都会向下层传递尺寸和规格。当 measure 方法遍历结束,每个视图都保存了各自的尺寸信息。第二个过程由 layout(int, int, int) 方法完成,该方法也是由上而下遍历视图树,在遍历过程中,每个父视图通过测量过程的结果定位所有子视图的位置信息。(参考自:《50 Android Hacks》)

简而言之:第一步,在 onMeasure() 方法中测量 ViewGroup 的尺寸;第二步,在 onLayout() 方法中利用第一步获取的尺寸信息布局子视图。

可以看一下流程图:

View 的绘制流程

(参考了:Android应用层View绘制流程与源码分析

说明:
关于 View 的绘制流程有不少结合源码分析的文章,有兴趣的可以去看看,如:
Android应用层View绘制流程与源码分析
Android中View绘制流程
这些不是本文重点,就不展开了。

创建自定义 ViewGroup

上面已经演示过要实现的效果了,下面我们使用自定义 ViewGroup 来实现。效果如下:

自定义ViewGroup实现扑克牌布局

创建 CascadeLayout

将自定义 ViewGroup 命名为 CascadeLayout,继承自 ViewGroup。根据 AndroidStudio 的提示,添加必要的方法:

1
2
3
4
5
6
7
8
9
10
11
12
public class CascadeLayout extends ViewGroup {

public CascadeLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {

}

}

定义自定义属性

我们要创建的 CascadeLayout 的实现思路是这样的,使用自定义属性来定制其内部子视图的位置。在 Framelayout 或者 RelativeLayout 中实现扑克牌布局使用的是 margin,也就是间距;再结合上面的效果图仔细分析一下,这完全可以通过规定视图的水平方向间距和垂直方向间距来实现。所以,可以定义两个属性,来分别表示水平方向间距和垂直方向间距。

1、创建属性文件
在 res/values 目录下创建一个 attrs.xml 文件,如果有这个文件那就直接打开。添加如下内容:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CascadeLayout">
<attr name="horizontal_spacing" format="dimension" />
<attr name="vertical_spacing" format="dimension" />
</declare-styleable>
</resources>

对于自定义属性不是很清楚的可以去看看这篇文章:Android 深入理解Android中的自定义属性,这里就不展开了。

2、在构造方法中获取自定义属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class CascadeLayout extends ViewGroup {

private int mHorizontalSpacing;
private int mVerticalSpacing;

public CascadeLayout(Context context, AttributeSet attrs) {
super(context, attrs);

TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.CascadeLayout);

try {
mHorizontalSpacing = a.getDimensionPixelSize(
R.styleable.CascadeLayout_horizontal_spacing,
getResources().getDimensionPixelSize(
R.dimen.cascade_horizontal_spacing));

mVerticalSpacing = a.getDimensionPixelSize(
R.styleable.CascadeLayout_vertical_spacing, getResources()
.getDimensionPixelSize(R.dimen.cascade_vertical_spacing));
} finally {
a.recycle();
}

}

}

3、重写 onMeasure() 方法
一般的布局 ViewGroup 中都会有一个取名为 LayoutParams 的静态内部类,在这里我们的 CascadeLayout 中也需要创建自定义 LayoutParamas 类,该类的作用是保存每个子视图的 x、y 轴位置。

先看看 LayoutParamas 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static class LayoutParams extends ViewGroup.LayoutParams {
int x;
int y;
public int verticalSpacing;

public LayoutParams(Context context, AttributeSet attrs) {
super(context, attrs);

TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.CascadeLayout_LayoutParams);
try {
verticalSpacing = a
.getDimensionPixelSize(
R.styleable.CascadeLayout_LayoutParams_layout_vertical_spacing,
-1);
} finally {
a.recycle();
}
}

public LayoutParams(int w, int h) {
super(w, h);
}

}

在该类中使用了子视图的自定义属性,该属性的作用是为特定子视图指定垂直间距。

第一步,在 attrs.xml 中添加一个新属性,如下:

1
2
3
<declare-styleable name="CascadeLayout_LayoutParams">
<attr name="layout_vertical_spacing" format="dimension" />
</declare-styleable>

因为属性名的前缀是 layout_,没有包含一个视图属性,因此该属性会被添加到 LayoutParamas 的属性表中。(这是从《50 Android Hacks》上直接摘抄下来的,话说我感觉不好理解)

第二步,在 LayoutParamas 类的构造函数中读取属性值
注意,LayoutParamas 中的 verticalSpacing 是一个公共字段,该字段会在 CascadeLayout 类的 onMeasure() 方法中使用到。如果子视图的 LayoutParamas 包含了 verticalSpacing,就可以使用它(也就是设置了 layout_vertical_spacing 属性,CascadeLayout_LayoutParams 中只定义了这一个属性)。

关于子视图的自定义属性就介绍到这,继续往下阅读。

要使用新定义的 CascadeLayout.LayoutParamas 类,还需要重写下面这些方法:

  • checkLayoutParams(ViewGroup.LayoutParams p)
  • generateDefaultLayoutParams()
  • generateLayoutParams(AttributeSet attrs)
  • generateLayoutParams(ViewGroup.LayoutParams p)

这些方法在不同的 ViewGroup 之间往往差别不大,关键是使用自己的静态内部类 LayoutParamas,这里给出简单的实现,你也可以参考一下 LinearLayout 是如何实现的,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof LayoutParams;
}

@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT);
}

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}

@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p.width, p.height);
}

好了,到 onMeasure() 方法了,这是 CascadeLayout 类的核心,里面有注释,直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 使用宽和高计算布局的最终大小以及子视图的 x 与 y 轴位置
int width = getPaddingLeft();
int height = getPaddingTop();
int verticalSpacing;

final int count = getChildCount();
for (int i = 0; i < count; i++) {
verticalSpacing = mVerticalSpacing;

View child = getChildAt(i);
// 让每个子视图测量自身
measureChild(child, widthMeasureSpec, heightMeasureSpec);

LayoutParams lp = (LayoutParams) child.getLayoutParams();
width = getPaddingLeft() + mHorizontalSpacing * i;

// 在 LayoutParamas 中保存每个子视图的 x 和 y 坐标
lp.x = width;
lp.y = height;

if (lp.verticalSpacing >= 0) {
verticalSpacing = lp.verticalSpacing;
}

width += child.getMeasuredWidth();
height += verticalSpacing;
}

width += getPaddingRight();
height += getChildAt(getChildCount() - 1).getMeasuredHeight()
+ getPaddingBottom();

// 使用计算所得的宽和高设置整个布局的测量尺寸
setMeasuredDimension(resolveSize(width, widthMeasureSpec),
resolveSize(height, heightMeasureSpec));
}

4、重写 onLayout() 方法

1
2
3
4
5
6
7
8
9
10
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
child.layout(lp.x, lp.y, lp.x + child.getMeasuredWidth(), lp.y
+ child.getMeasuredHeight());
}
}

这个方法就很简单了,遍历所有的子视图,以 onMeasure() 方法计算出的值为参数传入子视图的 layout() 方法中即可。

在布局中使用

直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<com.diygreen.androidhackslayout.view.CascadeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cascade="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_vertical_margin"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
cascade:layout_vertical_spacing="90dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#0000FF" />
</com.diygreen.androidhackslayout.view.CascadeLayout>

小结

使用自定义 View 和 ViewGroup 组织应用程序布局是一个好方法。建议大家多多模仿系统提供的 View 和 Layout,不仅可了解 Android 的设计思路,也可以加深对 Android 视图机制的理解。

提供我学习自定义 View 的思路:

  1. 了解自定义 View 的基本流程
  2. 学习简单的自定义 View 的实现
  3. 看一些开源项目

说白了就是从易到难,循序渐进。当然我认为先了解流程是比较重要的,你会知道阅读源码的时候从何处着手。

项目地址

AndroidHacks合集
布局篇
个人博客
示例用到代码见:
Hack5Activity.java
CascadeLayout.java
attrs.xml
activity_hack5.xml
activity_hack5_1.xml
activity_hack5_2.xml

参考

Android应用层View绘制流程与源码分析
Android中View绘制流程
Android 深入理解Android中的自定义属性

坚持原创技术分享,您的支持将鼓励我继续创作!